查看原文
其他

Button 的 "进化之旅" | 我们是如何设计 Compose API 的 (上篇)

Android Android 开发者 2021-10-19

本文由 Jetpack Compose 团队的 Louis Pullen-Freilich (软件工程师)、Matvei Malkov (软件工程师) 和 Preethi Srinivas (UX 研究员) 共同撰写。


近期 Jetpack Compose 发布了 1.0 版本,带来了一系列用于构建 UI 的稳定 API。今年早些时候,我们发布了 API 指南,介绍了编写 Jetpack Compose API 的最佳实践和 API 设计模式。经过多次迭代公共 API 接口 (API surface) 之后形成的指南,其实没有展示出这些设计模式的形成过程和我们在迭代过程中决策背后的故事。

 

  • API 指南
    https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md

本文将带您了解一个 "简单" 的 Button 的 "进化之旅",来深入了解我们是如何迭代设计 API,使其简单易用又不失灵活性。这个过程需要基于开发者的反馈,对 API 的可用性进行多次的适配和改进。

 


绘制可点击的矩形



Google 的 Android Toolkit 团队中有一个调侃: 我们所做的就是在屏幕上画一个带着颜色的矩形,并且让它可以被点击。事实证明,这是 UI toolkit 中最难实现的事情之一。
 
也许有人会认为,按钮是一个简单的组件: 只是一个有颜色的矩形,带有一个点击监听器。造成 Button API 设计复杂的原因有很多方面: 可发现性、参数的顺序和命名等等。另一个约束是灵活性: Button 提供了很多参数,可供开发者随意自定义各个元素。其中一些参数默认使用主题的配置,而一些参数可以基于其他参数的值。这样的搭配使得 Button API 的设计成为了一个很有意思的挑战。

我们针对 Button API 的第一个迭代版本,由两年前的一个 public commit 开始。当时的 API 就像下面这样:
@Composablefun Button( text: String, onClick: (() -> Unit)? = null, enabled: Boolean = true, shape: ShapeBorder? = null, color: Color? = null, elevation: Dp = 0.dp) { // 下面是具体实现}
△ 最初的 Button API
  • public commit

    https://github.com/androidx/androidx/commit/d4f91b3a79ced7473e21c7c000edd469d24c318b


除了名字外,最初的 Button API 与最终版本的代码相去甚远。它经历了多次迭代,我们将为大家展示这一过程:
@Composablefun Button( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, elevation: ButtonElevation? = ButtonDefaults.elevation(), shape: Shape = MaterialTheme.shapes.small, border: BorderStroke? = null, colors: ButtonColors = ButtonDefaults.buttonColors(), contentPadding: PaddingValues = ButtonDefaults.ContentPadding, content: @Composable RowScope.() -> Unit) { // 下面是具体实现}

△ 1.0 版本的 Button API



获得开发者反馈



在 Compose 的研究和实验阶段的早期,我们的 Button 组件可以接收一个 ButtonStyle 类型的参数。ButtonStyle 为 Button 定义了视觉相关的配置,比如颜色和形状。这使得我们可以展现三种不同的 Material Button 类型: 内含型 (Contained)、轮廓型 (Outlined) 和纯文本型 (Text);我们直接暴露顶层的构建函数,它会返回一个 ButtonStyle 实例,该实例对应 Material 规范中对应的按钮类型。开发者可以复制这些内置的按钮样式并微调,或者从头开始创建新的 ButtonStyle,从而完全重新设计自定义 Button。我们对于最初的 Button API 是比较满意的,这个 API 是可复用的,而且包含了易用的样式。

 

  • Material Button 类型

    https://material.io/components/buttons

  • ButtonStyle
    https://github.com/androidx/androidx/commit/401f755476bfb330bcf4580709a86b170f1c9442

为了验证我们的假设和设计方法,我们邀请开发者参与编程活动,并使用 Button API 完成简单的编程练习。编程练习中包括实现下图的界面:

△ 开发者所需开发的 Rally Material Study 的界面

  • Rally

    https://material.io/design/material-studies/rally.html


对这些代码开发的观察结果使用了 认知维度框架 (Cognitive Dimensions Framework) 进行复盘,以评估 Button API 的可用性
 
很快,我们观察到一个有趣的现象: 一些开发者一开始这样使用 Button API:
Button(text = "Refresh"){}

△ 使用 Button API

  • 认知维度框架 (Cognitive Dimensions Framework)
    https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.222.7327&rep=rep1&type=pdf
  • 可用性
    https://cacm.acm.org/magazines/2016/6/202645-improving-api-usability/fulltext


也有开发者尝试创建一个 Text 组件,然后使用圆角矩形围在文本的外围:
// 这里我们有 Padding 可组合函数,但是没有修饰符Padding(padding = 12.dp) { Column { Text(text = "Refresh", style = +themeTextStyle { body1 }) }}
△ 在 Text 上添加 Padding 来模拟一个 Button

当时使用样式 API,比如 themeShapethemeTextStyle,需要添加 + 操作符前缀。这是因为当时的 Compose Runtime 的特定限制造成的。开发者调查表明: 开发者发现很难理解此操作符的工作原理。从该现象中我们得到的启示是,不受设计者直接控制的 API 样式会影响开发者对 API 的认知。比如,我们了解到某位开发者对这里的操作符的评论是:


就我目前的理解,它是在复用一个已有的样式,或者基于该样式进行扩展。


大多数开发者认为 Compose API 之间出现了不一致性 —— 比如,对 Button 添加样式的方式与 Text 组件添加样式的方式不同*


*大多数开发者希望在样式前加上 "加号",使用  +themeButtonStyle 或者 +buttonStyle,类似他们对 Text 组件使用 +themeTextStyle 一样的方式。
 
此外,我们发现大多数开发者在 Button 上实现圆角边缘时,都经历了痛苦的过程,但是本来的预期是非常简单。通常,他们需要浏览多个层次的实现代码,来理解 API 的结构。
 

我感觉只是在这里随意堆叠了一些东西,没有信心能够使其发挥作用。

Button{ text = "Refresh", textStyle = +themeStyle {caption}, color = rallyGreen, shape = RoundedRectangleBorder(borderRadius = BorderRadius.circular(5.dp.value))}

△ 正确自定义 Button 的文字样式、颜色和形状

这就影响了开发者对 Button 设置样式的方式。比如,当为 Android 应用添加 Button 时,ContainedButtonStyle 是无法对应到开发者所已知的样式的。

△ 来自开发者研究的早期的感悟
通过举办的这些编程活动,我们体会到需要简化 Button API,来使其能够实现简单的自定义操作,同时支持复杂的应用场景。我们开始在可发现性和个性化上下功夫,而这两点为我们带来了接下来的一系列挑战: 样式和命名



保持 API 的一致性



在我们的编程活动中,样式给开发人员带来了很多问题。要洞悉其中的原因,我们先回溯一下为什么样式的概念存在于 Android 框架和其他工具包中。


"样式" 本质上是与 UI 相关的属性的集合,可被应用于组件 (如 Button)。样式包含两大主要优点:


1. 将 UI 配置与业务逻辑相剥离


在命令式工具包中,独立定义样式有助于分离关注点并且使代码更易于阅读: UI 可以在一个地方定义,比如 XML 文件中;而回调和业务逻辑可以在另外的地方定义和关联。


在类似 Compose 的声明式工具包中,会通过设计减少业务逻辑和 UI 的耦合。像 Button 这样的组件,大多是无状态的,它仅仅显示您所传递的数据。当数据更新时,您无需更新它的内部状态。由于组件也都是函数,可以通过向 Button 函数传参实现自定义,如其他函数的操作一样。但是这会增加将 UI 配置从功能配置中剥离的难度。比如,设置 Button 的 enabled = false ,不仅控制 Button 的功能,还会控制 Button 是否显示。


这就引出一个问题: enabled 应该是一个顶层的参数呢,还是应该在样式中作为一个属性进行传递?而对于可用于 Button 的其他样式呢,比如 elevation,或者当 Button 被点按时,它的颜色变化呢?设计可用 API 的一个核心原则是保持一致性。我们发现在不同的 UI 组件中,保证 API 的一致性是非常重要的。


2. 自定义一个组件的多个实例


在典型的 Android View 系统中,样式非常有优势,因为创建一个新的组件的成本很高: 您需要创建一个子类,实现构造方法,并且启用自定义属性。样式允许以一种更加简洁的方式,来表达一系列共享的属性。比如,创建一个 LoginButtonStyle,来定义应用中全部用于登录按钮的外观。在 Compose 中,实现如下所示:

val LoginButtonStyle = ButtonStyle( backgroundColor = Color.Blue, contentColor = Color.White, elevation = 5.dp, shape = RectangleShape)
Button(style = LoginButtonStyle) { Text(text = "LOGIN")}

△ 为登录按钮定义样式

现在可以在 UI 中的各种 Button 上使用 LoginButtonStyle,而无需在每个 Button 上显式设置这些参数。然而,如果您也希望提取文本,让所有的登录按钮都显示相同的文本: "LOGIN",该怎么办呢?
 
在 Compose 中,每个组件都是一个函数,所以常规的解决方法是定义一个函数,其中调用 Button,并且为 Button 提供正确的文本:
@Composablefun LoginButton( onClick: () -> Unit, modifier: Modifier = Modifier) { Button(onClick = onClick, modifier = modifier, style = LoginButtonStyle) { Text(text = "LOGIN") }}
△ 创建一个在语义上表达了其含义的 LoginButton 函数
由于组件先天的无状态特性,以这样的方式提炼函数的成本是很低的: 参数可以直接从封装的函数,传递给内部的按钮。由于您并不是继承一个类,所以仅暴露需要的参数;剩下的可以留在 LoginButton 的内部实现体中,从而避免颜色和文本被覆盖。这样的方式适用于很多自定义场景,超过样式所涵盖的范围。

此外,相比在 Button 上设置 LoginButtonStyle,创建一个 LoginButton 函数,可以具有更多的语义上的含义。我们也在研究过程中发现: 相比样式,独立的函数更具有可发现性。

没有了样式,LoginButton 现在可以重构为直接向其中的 Button 传参,而无需使用样式对象,这样就能与其他自定义操作保持一致:
@Composablefun LoginButton( onClick: () -> Unit, modifier: Modifier = Modifier) { Button( onClick = onClick, modifier = modifier, shape = RectangleShape, elevation = ButtonDefaults.elevation(defaultElevation = 5.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Blue, contentColor = Color.White) ) { Text(text = "LOGIN") }}

△ 最终的 LoginButton 实现

最终我们去掉样式,并且将参数扁平化到组件中 —— 一方面是为了整体 Compose 设计的一致性,另一方面是鼓励开发者创建更具语义特征的 "封装" 函数:
@Composableinline fun OutlinedButton( modifier: Modifier = Modifier.None, noinline onClick: (() -> Unit)? = null, backgroundColor: Color = MaterialTheme.colors().surface, contentColor: Color = MaterialTheme.colors().primary, shape: Shape = MaterialTheme.shapes().button, border: Border? = Border(1.dp, MaterialTheme.colors().onSurface.copy(alpha = OutlinedStrokeOpacity)), elevation: Dp = 0.dp, paddings: EdgeInsets = ButtonPaddings, noinline children: @Composable() () -> Unit) = Button( modifier = modifier, onClick = onClick, backgroundColor = backgroundColor, contentColor = contentColor, shape = shape, border = border, elevation = elevation, paddings = paddings, children = children)

△ 1.0 版本中的 OutlinedButton

  • 去掉样式

    https://github.com/androidx/androidx/commit/e386f97dd769da18d9f3103958714e43d797219f



提高 API 的可发现性或可见性



我们还在研究中发现,在如何设置按钮形状方面存在一个重大缺陷。要自定义 Button 的形状,开发者可以使用 shape 参数,它可接受一个 Shape 对象。当开发者需要新建一个带有切角的按钮时,通常可通过如下方式实现:
  1. 使用默认值创建一个简单的 Button
  2. 从 MaterialTheme.kt 源文件中参考关于形状的主题设置相关的内容
  3. 再回看 MaterialButtonShapeTheme 函数
  4. 找到 RoundedCornerShape,并且使用类似的方法创建一个带有切角的 shape

‍大多数开发者在这里会感到迷惑,在浏览大量 API 和源代码时,常常会不知所措。我们发现开发者不易发现 CutCornerShape,这是因为它是从与其他的 shape API 不同的包里所暴露出来的。‍‍‍

可见性用于衡量开发者达到其目标时,定位函数或者参数的难易程度。它和编写代码所需的认知过程所付出的精力直接相关;用于探索发现和使用一个方法的路径越深,API 的可见性越差。最终,这会导致较低的效率和较差的开发者体验。基于这样的认知,我们 CutCornerShape 迁移到与其他 shape API 相同的包中,来支持便捷的可发现性。


  •  CutCornerShape 迁移
    https://github.com/androidx/androidx/commit/468c797c109cf24a561dad6a496310964d2a4a2b


小结



本文以 Jetpack Compose 中 Button API 作为切入点,从原型设计出发,为大家展示了研发团队如何基于开发者反馈、API 一致性和 API 可发现性三个角度,对公共 API 接口进行不断迭代和优化,以及在过程中所遇到的问题。

 
在下一篇文章中,我们将更深入剖析 API 的设计之路。如果您在使用 Compose 时遇到任何问题,或者对新 API 的体验提升有任何建议和想法,请告诉我们。欢迎广大开发者参与到我们接下来的用户调研活动中,期待您的注册报名。

 

  • 问题提出和建议反馈

    https://issuetracker.google.com/issues/new?component=612128&template=1253476

  • Google 用户体验调研

    https://google.qualtrics.com/jfe/form/SV_3NMIMtX0F2zkakR?reserved=1&utm_source=Survey&Q_Language=en&utm_medium=own_evt&utm_campaign=Q1&productTag=adstu&campaignDate=February2021&referral_code=UXSf298725

也欢迎您通过下方二维码向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!



推荐阅读

如页面未加载,请刷新重试

 点击屏末 | 阅读原文 | 即刻了解使用 Jetpack Compose 打造更出色的应用




视频 小程序 ,轻点两下取消赞 在看 ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存